Python 51.6%
TypeScript 46.7%
CSS 1.7%
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { apiSafe } from "@/lib/api";5import { PartyBadge } from "@/components/party";6import { Badge, Card, KV, SectionHeader, SourceBadge } from "@/components/ui";7import { dateTimeFr } from "@/lib/format";8import { partyVar, partyName } from "@/lib/parties";910export const revalidate = 300;1112interface Doc { id: number; party: string; title: string; url: string; type: string; status: string; discoveredAt: string; lastCheckedAt: string | null;13 versions: { id: number; sha256: string; fetchedAt: string; bytes: number; pages: number | null; contentType: string | null; analyzedAt: string | null; diff: { ratio: number; added: string[]; removed: string[]; added_count: number; removed_count: number } | null; current: boolean; archiveUrl: string }[];14 proposals: { id: number; topic: string; topicLabel: string; subtopic: string | null; proposal: string; position: string | null; direction: string | null; magnitude: string | null; page: number | null; quote: string; confidence: number; status: string }[] }1516export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {17 const { id } = await params;18 const d = await apiSafe<Doc>(`/api/documents/${id}`);19 return { title: d ? `${d.title} — ${partyName(d.party)}` : "Document" };20}2122export default async function Page({ params }: { params: Promise<{ id: string }> }) {23 const { id } = await params;24 const d = await apiSafe<Doc>(`/api/documents/${id}`);25 if (!d) notFound();26 const cur = d.versions.find((v) => v.current) ?? d.versions[d.versions.length - 1];27 const byTopic: Record<string, Doc["proposals"]> = {};28 d.proposals.filter((p) => p.status === "accepted").forEach((p) => { (byTopic[p.topicLabel] ??= []).push(p); });29 return (30 <div className="space-y-8">31 <div><Link href="/documents" className="text-[12.5px] text-ink-3 hover:text-ink">← Bibliothèque</Link>32 <div className="flex items-center gap-2 mt-3 flex-wrap"><PartyBadge id={d.party} /><Badge>{d.type.replace("_", " ")}</Badge>{cur?.contentType?.includes("pdf") && <Badge>PDF · {cur.pages} pages</Badge>}</div>33 <h1 className="display text-[30px] md:text-[42px] mt-3">{d.title}</h1>34 <div className="mt-2 flex items-center gap-3 flex-wrap text-[13.5px]"><a href={d.url} target="_blank" rel="noopener noreferrer" className="link break-all">{d.url}</a><SourceBadge source={{ name: `Site officiel — ${partyName(d.party)}`, url: d.url, archiveUrl: cur?.archiveUrl, retrievedAt: cur?.fetchedAt, tier: "Officiel (parti)" }} /></div>35 </div>36 <div className="grid lg:grid-cols-[1fr_320px] gap-6 items-start">37 <div className="space-y-6">38 {Object.keys(byTopic).length === 0 ? <Card><div className="text-ink-3 text-[13.5px]">Aucune proposition acceptée n'a été extraite de ce document (page de navigation, contenu non politique, ou analyse en attente).</div></Card> : Object.entries(byTopic).map(([label, ps]) => (39 <section key={label}>40 <SectionHeader title={label} eyebrow={`${ps.length} propositions`} />41 <div className="space-y-2">{ps.map((p) => (42 <Card key={p.id} className="scroll-mt-24" >43 <div id={`p${p.id}`} className="grid md:grid-cols-[1fr_auto] gap-3">44 <div><div className="font-medium text-[14.5px]">{p.proposal}</div>{p.position && <div className="text-[13px] text-ink-2 mt-1">{p.position}</div>}{p.magnitude && <div className="num text-[13px] mt-1">{p.magnitude}</div>}45 <blockquote className="mt-3 italic text-ink-2 text-[13px] border-l-2 pl-3 leading-relaxed" style={{ borderColor: partyVar(d.party) }}>« {p.quote} »</blockquote></div>46 <div className="text-[12px] text-ink-3 num md:text-right shrink-0">{p.page ? <div>Page {p.page}</div> : null}<div>Confiance {Math.round(p.confidence * 100)} %</div>{p.direction && p.direction !== "neutre" && <div className="mt-1"><Badge>{p.direction.replace(/_/g, " ")}</Badge></div>}{p.subtopic && <div className="mt-1">{p.subtopic}</div>}</div>47 </div>48 </Card>))}</div>49 </section>50 ))}51 </div>52 <div className="space-y-4">53 <Card><div className="eyebrow mb-2">Fiche</div><KV items={[["Parti", partyName(d.party)], ["Type", d.type], ["Découvert", dateTimeFr(d.discoveredAt)], ["Vérifié", dateTimeFr(d.lastCheckedAt)], ["Versions", String(d.versions.length)], ["Propositions", String(d.proposals.filter((p) => p.status === "accepted").length)], ["En révision", String(d.proposals.filter((p) => p.status === "review").length)]]} /></Card>54 <Card><div className="eyebrow mb-2">Versions</div><ul className="space-y-3 text-[12.5px]">{d.versions.slice().reverse().map((v) => (<li key={v.id} className="border-l-2 pl-3" style={{ borderColor: v.current ? partyVar(d.party) : "var(--border)" }}><div className="flex justify-between"><b>{dateTimeFr(v.fetchedAt)}</b>{v.current && <Badge tone="accent">courante</Badge>}</div><div className="mono text-[11px] text-ink-3 break-all mt-0.5">{v.sha256}</div><div className="text-ink-2 mt-0.5 num">{(v.bytes / 1024).toFixed(0)} Ko{v.pages ? ` · ${v.pages} p.` : ""} · <a href={v.archiveUrl} className="link" target="_blank" rel="noopener noreferrer">archive QC26</a></div>{v.diff && <div className="mt-1 text-ink-2">Δ vs version précédente : similarité {Math.round(v.diff.ratio * 100)} %, +{v.diff.added_count} / −{v.diff.removed_count} lignes</div>}</li>))}</ul></Card>55 {cur?.diff && (cur.diff.added.length > 0 || cur.diff.removed.length > 0) && (56 <Card><div className="eyebrow mb-2">Document diff</div><div className="text-[12px] space-y-1 max-h-[360px] overflow-auto">{cur.diff.added.slice(0, 30).map((l, i) => <div key={`a${i}`} className="bg-[rgba(21,128,61,.08)] text-ok rounded px-1.5 py-0.5">+ {l}</div>)}{cur.diff.removed.slice(0, 30).map((l, i) => <div key={`r${i}`} className="bg-[rgba(220,38,38,.07)] text-live rounded px-1.5 py-0.5 line-through">− {l}</div>)}</div></Card>57 )}58 </div>59 </div>60 </div>61 );62}63